热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

可能会|表里_深入理解DiskLruCache源码

篇首语:本文由编程笔记#小编为大家整理,主要介绍了深入理解DiskLruCache源码相关的知识,希望对你有一定的参考价值。作

篇首语:本文由编程笔记#小编为大家整理,主要介绍了深入理解DiskLruCache源码相关的知识,希望对你有一定的参考价值。




作者:岩浆李的游鱼leo2



前言

我们在用第三方框架的时候,比如glide,okhttp等,使用起来已经轻而易举,因为我们只管用。忽视了其用到的缓存技术。让我们一起来理解下缓存的本质。内存缓存一般现在用的是LruCache缓存,磁盘缓存是DiskLruCache。它们用的都是LRU算法(最近最少使用)。网络缓存当然就是网络请求,数据放在后端数据库了。

LRU算法:Least Recently Used 即为近期最少使用。在缓存数据的时候,如果数据不存在缓存中,则放入缓存中,如存在缓存中,会将缓冲重新放入头部位置表示最近使用了,底部位置则为近期最少使用,在一些配置下,如果达到了缓存的容量下,那么要缓存新的数据,则需要从底部最近最少使用的开始删除缓存。

看到这里,其实LRU也有他的缺点。比如遇到这样的场景,一般电商都会有秒杀日,那么一旦出现秒杀日,那么在秒杀日当天可能会因为缓存容量的问题,把真正潜在经常访问的缓存给删除。怎么优化的,其实也有办法,LRU+2Q。有点偏题了,感兴趣的同学可以自行了解。

接下来跟着我节奏,我们将DiskLruCache各个击破。当然你需要先下载一份DiskLruCache源码。
点这里-源码地址;摩拜JakeWharton大神


一、DiskLruCache的创建

DiskLruCache mDiskLruCache = null;
try
File cacheDir = getDiskCacheDir(this, "bitmap");
if (!cacheDir.exists())
cacheDir.mkdirs();

mDiskLruCache = DiskLruCache.open(cacheDir, getVersionCode(this), 1, 10 * 1024 * 1024);
catch (IOException e)
e.printStackTrace();

做完以上呢我们打开手机的缓存文件,因为android手机太杂,老一点带sd卡的你可以从这个路径里去找/sdcard/Android/data/项目包名/cache/bitmap/。内置储存卡的,可以通过点击手机内部存储–> Android/data/项目包名/cache/bitmap/ 在这个文件夹可以看到一个 journal文件。先介绍完open后,会介绍journal文件的

可以看到DiskLruCahe是通过open创建的,其中包括4个参数,我们来看下源码。源码就直接给了注解,我会直接翻译成中文

/**
* 参数1:directory 缓存路径位置,如果缓存文件不存在就新建
* 参数2:appVersion 当前app的版本号,也就是versionCode
* 参数3:valueCount 一个缓存key的缓存个数。一般传1,表示1个key对应1个缓存
* 参数4:maxSize 缓存的最大容量
*/

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
throws IOException
if (maxSize <&#61; 0)
throw new IllegalArgumentException("maxSize <&#61; 0");

if (valueCount <&#61; 0)
throw new IllegalArgumentException("valueCount <&#61; 0");

//new出当前文件&#xff0c;也就是对成员变量赋值&#xff0c;如directory&#xff0c;appVersion&#xff0c;valueCount&#xff0c;journalFile等
DiskLruCache cache &#61; new DiskLruCache(directory, appVersion, valueCount, maxSize);
//那为什么我们调用的方法是open呢? 看下面的判断的这个判断&#xff0c;大致意思是&#xff1a;
//如果我们传入的参数&#xff0c;和原始缓存文件一致&#xff0c;那么返回原始缓存文件&#xff0c;并在上面拼接
//如果缓存文件journal存在的话
if (cache.journalFile.exists())
try
//就是去读原始缓存文件里的参数&#xff0c;和现在赋值的参数是否一致&#xff0c;不一致的话抛出异常
//同时将日志信息读取到我们的缓存链表里LinkedHashMap lruEntries 这里会放到【1.1、readJournal()】详细讲解
cache.readJournal();
//这里会将上次读取还处于头部文件为 DIRTY 即还未写入缓存的脏数据清掉。会放到【1.2、processJournal()】详细讲解
cache.processJournal();
//看这个append为true&#xff0c;内部调用的是new FileOutputStream(file, append)&#xff0c;输出流在原始文件上拼接
//如果这个参数为false的话&#xff0c;会把原始文件内容清掉&#xff0c;重新写。断点下载就是这个原理。有兴趣的同学可自了解
cache.journalWriter &#61; new BufferedWriter(new FileWriter(cache.journalFile, true),
IO_BUFFER_SIZE);
return cache;
catch (IOException journalIsCorrupt)
//抛出异常的话&#xff0c;那么把原始缓存文件删除&#xff0c;在下面新建缓存文件
cache.delete();


//如果走到这一步&#xff0c;说明上面没有把原始缓存return出去。且还走了catch里把原始缓存文件删除了&#xff0c;那么按新的参数&#xff0c;生成缓存文件
directory.mkdirs();
cache &#61; new DiskLruCache(directory, appVersion, valueCount, maxSize);
//重建缓存文件&#xff0c;会放到【1.3、rebuildJournal();】详细讲解
cache.rebuildJournal();
return cache;

创建缓存代码也贴上来&#xff0c;方便读者阅览。

// 获取缓存路径&#xff0c;这里已经做好了判断是否有外部存储
public File getDiskCacheDir(Context context, String uniqueName)
String cachePath;
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
|| !Environment.isExternalStorageRemovable())
cachePath &#61; context.getExternalCacheDir().getPath();
else
cachePath &#61; context.getCacheDir().getPath();

return new File(cachePath &#43; File.separator &#43; uniqueName);

//获取app当前的版本号versionCode
public int getVersionCode(Context context)
PackageManager packageManager &#61; context.getPackageManager();
PackageInfo packageInfo;
int versionCode &#61; 1;
try
packageInfo &#61; packageManager.getPackageInfo(context.getPackageName(), 0);
versionCode &#61; packageInfo.versionCode;
catch (PackageManager.NameNotFoundException e)
e.printStackTrace();

return versionCode;


1.1、readJournal()

private void readJournal() throws IOException
//把journal文件通过输入流读入
InputStream in &#61; new BufferedInputStream(new FileInputStream(journalFile), IO_BUFFER_SIZE);
try
//这几句就是读出原始缓存文件journal的参数和现在通过open传入的参数进行对比&#xff0c;如果参数不一致那么会抛出异常
String magic &#61; readAsciiLine(in);
String version &#61; readAsciiLine(in);
String appVersionString &#61; readAsciiLine(in);
String valueCountString &#61; readAsciiLine(in);
String blank &#61; readAsciiLine(in);
if (!MAGIC.equals(magic)
|| !VERSION_1.equals(version)
|| !Integer.toString(appVersion).equals(appVersionString)
|| !Integer.toString(valueCount).equals(valueCountString)
|| !"".equals(blank))
throw new IOException("unexpected journal header: ["
&#43; magic &#43; ", " &#43; version &#43; ", " &#43; valueCountString &#43; ", " &#43; blank &#43; "]");

while (true)
try
//把缓存的日志信息&#xff0c;读入到我们的缓存链表LinkedHashMap lruEntries
//这里还有一个操作&#xff0c;把头部REMOVE的日志清掉&#xff0c;属于冗余日志
readJournalLine(readAsciiLine(in));
catch (EOFException endOfJournal)
break;


finally
closeQuietly(in);


再来看下readJournalLine()的源码

private void readJournalLine(String line) throws IOException
String[] parts &#61; line.split(" ");
if (parts.length < 2)
throw new IOException("unexpected journal line: " &#43; line);

String key &#61; parts[1];
//这里就是日志信息里的已经REMOVE的缓存&#xff0c;从缓存列表里移除
if (parts[0].equals(REMOVE) && parts.length &#61;&#61; 2)
lruEntries.remove(key);
return;

Entry entry &#61; lruEntries.get(key);
if (entry &#61;&#61; null)
entry &#61; new Entry(key);
lruEntries.put(key, entry);

//读出头部为CLEAN&#xff0c;表示已经缓存成功的数据的一些信息。并复赋值到缓存列表里
//缓存列表里存的Entry对象&#xff0c;其句是DiskLruCache定义的内部类&#xff0c;用于存储缓存信息用的。
if (parts[0].equals(CLEAN) && parts.length &#61;&#61; 2 &#43; valueCount)
entry.readable &#61; true;
entry.currentEditor &#61; null;
entry.setLengths(copyOfRange(parts, 2, parts.length));
else if (parts[0].equals(DIRTY) && parts.length &#61;&#61; 2)
entry.currentEditor &#61; new Editor(entry);
else if (parts[0].equals(READ) && parts.length &#61;&#61; 2)
// this work was already done by calling lruEntries.get()
else
throw new IOException("unexpected journal line: " &#43; line);


这个时候你可能会想了&#xff0c;即使不断的操作缓存&#xff0c;是不是journal日志文件会越来越大呢&#xff1f;引用一下郭神的原话



如果我不停频繁操作的话&#xff0c;就会不断地向journal文件中写入数据&#xff0c;那这样journal文件岂不是会越来越大&#xff1f;这倒不必担心&#xff0c;DiskLruCache中使用了一个redundantOpCount变量来记录用户操作的次数&#xff0c;每执行一次写入、读取或移除缓存的操作&#xff0c;这个变量值都会加1&#xff0c;当变量值达到2000的时候就会触发重构journal的事件&#xff0c;这时会自动把journal中一些多余的、不必要的记录全部清除掉&#xff0c;保证journal文件的大小始终保持在一个合理的范围内。


然后我仔细阅读了源码发现&#xff0c;其实在每次操作get()&#xff0c;completeEdit()&#xff0c;remove都调用了一句这样的代码

if (journalRebuildRequired())
executorService.submit(cleanupCallable);

那么看journalRebuildRequired()&#xff0c;字面意思很明显&#xff0c;jornal文件是否需要被重建

//一看源码和郭神所说一致
private boolean journalRebuildRequired()
final int REDUNDANT_OP_COMPACT_THRESHOLD &#61; 2000;
return redundantOpCount >&#61; REDUNDANT_OP_COMPACT_THRESHOLD
&& redundantOpCount >&#61; lruEntries.size();

不难想到cleanupCallable里发生了什么&#xff0c;这里除了缓存超过最大缓存&#xff0c;清理缓存外&#xff0c;也判断了journal文件是否需要被重建。将redundantOpCount置为0

if (journalRebuildRequired())
rebuildJournal();
redundantOpCount &#61; 0;


1.2、processJournal()

processJournal()是在执行完readJournal()后执行的。此时缓存文件信息已全部读入到了缓存列表里LinkedHashMap lruEntries。

processJournal()其实就是把上次还未缓存成功&#xff0c;停留在头部信息为DIRTY的缓存数据&#xff0c;即为脏数据给清除掉。&#xff08;数据缓存成功即有2行缓存日志&#xff0c;头部信息分别为DIRTY&#xff0c;CLEAN&#xff1b;移除成功&#xff0c;头部信息也有2行分别为DIRTY&#xff0c;REMOVE&#xff1b;会在讲解journal说清楚&#xff0c;现在跟着“感觉”走就行了&#xff09;上源码

private void processJournal() throws IOException
//这个时候还做了个操作把journalFileTmp也就是journal的临时文件删除了&#xff0c;
//因为这个时候没走rebuildJournal()重建journal文件的方法&#xff0c;所以journalFileTmp已经没有存在的必要了&#xff0c;删除文件
deleteIfExists(journalFileTmp);
for (Iterator<Entry> i &#61; lruEntries.values().iterator(); i.hasNext(); )
Entry entry &#61; i.next();
//已经被缓存&#xff0c;那么缓存size&#43;&#43;
if (entry.currentEditor &#61;&#61; null)
for (int t &#61; 0; t < valueCount; t&#43;&#43;)
size &#43;&#61; entry.lengths[t];

else
//没有被缓存&#xff0c;那么清除掉这条脏数据
entry.currentEditor &#61; null;
for (int t &#61; 0; t < valueCount; t&#43;&#43;)
deleteIfExists(entry.getCleanFile(t));
deleteIfExists(entry.getDirtyFile(t));

i.remove();




1.3、rebuildJournal()

这是在open&#xff08;&#xff09;方法内&#xff0c;参数不一致&#xff0c;最终会走到journal文件重建的方法

private synchronized void rebuildJournal() throws IOException
if (journalWriter !&#61; null)
journalWriter.close();

//这块就是journal的头信息&#xff0c;你也可以直接把他认为是参数信息
Writer writer &#61; new BufferedWriter(new FileWriter(journalFileTmp), IO_BUFFER_SIZE);
writer.write(MAGIC);
writer.write("\\n");
writer.write(VERSION_1);
writer.write("\\n");
writer.write(Integer.toString(appVersion));
writer.write("\\n");
writer.write(Integer.toString(valueCount));
writer.write("\\n");
writer.write("\\n");
//把当前已经缓存成功的日志信息写上去
for (Entry entry : lruEntries.values())
if (entry.currentEditor !&#61; null)
writer.write(DIRTY &#43; &#39; &#39; &#43; entry.key &#43; &#39;\\n&#39;);
else
writer.write(CLEAN &#43; &#39; &#39; &#43; entry.key &#43; entry.getLengths() &#43; &#39;\\n&#39;);


writer.close();
//注意&#xff0c;这里先是把数据写进临时文件journalFileTmp里&#xff0c;最后改名为journalFile里
//其实这个时候我也在想&#xff0c;这个临时文件就是个多余的存在。纵观了代码&#xff0c;也就在这起了点作用。journalFile文件在前面
//被delete掉了&#xff0c;难道不能在这里从新new下&#xff0c;取代这个journalFileTmp吗&#xff1f;我觉得是可以的&#xff0c;可能大神想把初始化工作都
//放在open()里吧
journalFileTmp.renameTo(journalFile);
journalWriter &#61; new BufferedWriter(new FileWriter(journalFile, true), IO_BUFFER_SIZE);


二、journal文件

本来想把这个放在最后说&#xff0c;起着画龙点睛的左右。但是前面提了这么多&#xff0c;如果有看到这的朋友&#xff0c;其实再这点这龙的眼睛是最合适的。承上启下&#xff0c;贯穿全文。

通过上面的创建&#xff0c;我们打开手机&#xff0c;可以看到journal文件生成了。

因为我是新建的&#xff0c;里面还没存数据&#xff0c;所以日志信息是不全的。加上缓存信息&#xff0c;是第三步的内容。所以暂且用源码的地址讲解&#xff0c;大神也注释的非常清楚了。这里我们只要知道操作缓存的话&#xff0c;这些操作信息都是会被记录到journal文件里的。

/*
* libcore.io.DiskLruCache
* 1
* 1
* 1
*
* CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
* DIRTY 335c4c6028171cfddfbaae1a9c313c52
* CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
* REMOVE 335c4c6028171cfddfbaae1a9c313c52
* DIRTY 1ab96a171faeeee38496d8b330771a7a
* CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
* READ 335c4c6028171cfddfbaae1a9c313c52
* READ 3400330d1dfc7f3f7f4b8d4d803dfcf6
*/


  • 第一行&#xff1a;libcore.io.DiskLruCache 标志着我们使用的是DiskLruCache技术
  • 第二行&#xff1a;1 DiskLruCache的版本号&#xff0c;这个值是恒为1的
  • 第三行: 1 app的版本号&#xff0c;也就是对应着我们从open里传入的值
  • 第四行&#xff1a;1 即valueCount&#xff0c;如果是1那么我们的key和缓存是一对一的关系
  • 第五行&#xff1a;空行&#xff0c;前五行被成为journal文件的头

DIRTY头部 : 目前这是一条脏数据&#xff0c;正准备缓存&#xff0c;但还未缓存成功。后面跟着的是key值&#xff0c;因为需要一一对应&#xff0c;且不允许带符号&#xff0c;这个key是通过图片url地址进行MD5加密得来

CLEAN头部 : 该key值对应的缓存数据&#xff0c;已成功缓存。key值后面对应的数字是缓存的大小&#xff0c;源码里跟了2个&#xff0c;说明他valueCount设置成了2&#xff0c;一个key对应多个缓存

REMOVE头部 : 该key值对应的缓存数据&#xff0c;被清除了

READ头部 : 该key值对应的缓存数据&#xff0c;被使用了

可以看到每一行DIRTY的key&#xff0c;后面都应该有一行对应的CLEAN或者REMOVE的记录&#xff0c;否则这条数据就是“脏”的&#xff0c;会被自动删除掉。我们在第1小结里&#xff0c;已经讲到过了。journal大概介绍就讲完了。如果你对下方知识点不管感兴趣&#xff0c;可以跳过

这个时候可能会有一个疑问&#xff0c;为什么key还能一对多呢&#xff1f;这里引入一个知识点&#xff0c;结合这个知识点&#xff0c;可能你就理解了。没错哈希碰撞

什么是 哈希碰撞 &#xff1f;
我们要存数据&#xff0c;首先要把存储的key值转换成系统认识的标记。举个列子&#xff0c;字母‘a’和数子97的ASCII码都是 97&#xff0c;转换成二进制是&#xff1a;01100001。所以这样就造成了哈希碰撞&#xff0c;即key计算出来的hashcode是一样的值。

那么解决了hash碰撞的问题&#xff0c;岂不是同一key可以对应多个值&#xff1f;有很多解决方式&#xff0c;这里我们说下拉链法&#xff1a;即如果出现哈希碰撞的key,对应的不是一个value&#xff0c;而是一个单项链表的地址。先看下其数据结构:

static class Entry<K,V> implements Map.Entry<K,V>
final K key;
V value;
Entry<K,V> next;
int hash;...

单项列表就是通过next,next&#xff0c;有碰撞就存下去。当我们通过key去取value的时候&#xff0c;这个key对应的是哈希碰撞的话&#xff0c;就会取到一个单列表&#xff0c;然后通过遍历&#xff08;因为数据结构里有具体的key值&#xff0c;key值对应上&#xff09;&#xff0c;得到最终的value。因为是遍历的&#xff0c;所以这里并不适合存太长&#xff0c;size满8的话会转换成红黑树&#xff0c;提高查询效率。


三、写入缓存


3.1、DiskLruCache.Editor

写入缓存是通过DiskLruCache.Editor 这类&#xff0c;后面跟的是写入缓存的key&#xff0c;这个key呢我们只要保证不带什么特殊符号且唯一性即可&#xff0c;这里用郭神的方案&#xff0c;我们使用MD5加密

try
String imageUrl &#61; "https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/54a4b40807034b3090708c935689345f~tplv-k3u1fbpfcp-zoom-crop-mark:1304:1304:1304:734.awebp?";
String key &#61; hashKeyForDisk(imageUrl);
DiskLruCache.Editor editor &#61; mDiskLruCache.edit(key);
catch (IOException e)
e.printStackTrace();

public String hashKeyForDisk(String key)
String cacheKey;
try
final MessageDigest mDigest &#61; MessageDigest.getInstance("MD5");
mDigest.update(key.getBytes());
cacheKey &#61; bytesToHexString(mDigest.digest());
catch (NoSuchAlgorithmException e)
cacheKey &#61; String.valueOf(key.hashCode());

return cacheKey;

private String bytesToHexString(byte[] bytes)
StringBuilder sb &#61; new StringBuilder();
for (int i &#61; 0; i < bytes.length; i&#43;&#43;)
String hex &#61;

推荐阅读
  • 在Android开发中,使用Picasso库可以实现对网络图片的等比例缩放。本文介绍了使用Picasso库进行图片缩放的方法,并提供了具体的代码实现。通过获取图片的宽高,计算目标宽度和高度,并创建新图实现等比例缩放。 ... [详细]
  • Android Studio Bumblebee | 2021.1.1(大黄蜂版本使用介绍)
    本文介绍了Android Studio Bumblebee | 2021.1.1(大黄蜂版本)的使用方法和相关知识,包括Gradle的介绍、设备管理器的配置、无线调试、新版本问题等内容。同时还提供了更新版本的下载地址和启动页面截图。 ... [详细]
  • XML介绍与使用的概述及标签规则
    本文介绍了XML的基本概念和用途,包括XML的可扩展性和标签的自定义特性。同时还详细解释了XML标签的规则,包括标签的尖括号和合法标识符的组成,标签必须成对出现的原则以及特殊标签的使用方法。通过本文的阅读,读者可以对XML的基本知识有一个全面的了解。 ... [详细]
  • 本文介绍了Android 7的学习笔记总结,包括最新的移动架构视频、大厂安卓面试真题和项目实战源码讲义。同时还分享了开源的完整内容,并提醒读者在使用FileProvider适配时要注意不同模块的AndroidManfiest.xml中配置的xml文件名必须不同,否则会出现问题。 ... [详细]
  • C++字符字符串处理及字符集编码方案
    本文介绍了C++中字符字符串处理的问题,并详细解释了字符集编码方案,包括UNICODE、Windows apps采用的UTF-16编码、ASCII、SBCS和DBCS编码方案。同时说明了ANSI C标准和Windows中的字符/字符串数据类型实现。文章还提到了在编译时需要定义UNICODE宏以支持unicode编码,否则将使用windows code page编译。最后,给出了相关的头文件和数据类型定义。 ... [详细]
  • 开发笔记:加密&json&StringIO模块&BytesIO模块
    篇首语:本文由编程笔记#小编为大家整理,主要介绍了加密&json&StringIO模块&BytesIO模块相关的知识,希望对你有一定的参考价值。一、加密加密 ... [详细]
  • android listview OnItemClickListener失效原因
    最近在做listview时发现OnItemClickListener失效的问题,经过查找发现是因为button的原因。不仅listitem中存在button会影响OnItemClickListener事件的失效,还会导致单击后listview每个item的背景改变,使得item中的所有有关焦点的事件都失效。本文给出了一个范例来说明这种情况,并提供了解决方法。 ... [详细]
  • baresip android编译、运行教程1语音通话
    本文介绍了如何在安卓平台上编译和运行baresip android,包括下载相关的sdk和ndk,修改ndk路径和输出目录,以及创建一个c++的安卓工程并将目录考到cpp下。详细步骤可参考给出的链接和文档。 ... [详细]
  • 图解redis的持久化存储机制RDB和AOF的原理和优缺点
    本文通过图解的方式介绍了redis的持久化存储机制RDB和AOF的原理和优缺点。RDB是将redis内存中的数据保存为快照文件,恢复速度较快但不支持拉链式快照。AOF是将操作日志保存到磁盘,实时存储数据但恢复速度较慢。文章详细分析了两种机制的优缺点,帮助读者更好地理解redis的持久化存储策略。 ... [详细]
  • 自动轮播,反转播放的ViewPagerAdapter的使用方法和效果展示
    本文介绍了如何使用自动轮播、反转播放的ViewPagerAdapter,并展示了其效果。该ViewPagerAdapter支持无限循环、触摸暂停、切换缩放等功能。同时提供了使用GIF.gif的示例和github地址。通过LoopFragmentPagerAdapter类的getActualCount、getActualItem和getActualPagerTitle方法可以实现自定义的循环效果和标题展示。 ... [详细]
  • Android系统移植与调试之如何修改Android设备状态条上音量加减键在横竖屏切换的时候的显示于隐藏
    本文介绍了如何修改Android设备状态条上音量加减键在横竖屏切换时的显示与隐藏。通过修改系统文件system_bar.xml实现了该功能,并分享了解决思路和经验。 ... [详细]
  • CentOS 6.5安装VMware Tools及共享文件夹显示问题解决方法
    本文介绍了在CentOS 6.5上安装VMware Tools及解决共享文件夹显示问题的方法。包括清空CD/DVD使用的ISO镜像文件、创建挂载目录、改变光驱设备的读写权限等步骤。最后给出了拷贝解压VMware Tools的操作。 ... [详细]
  • 本文介绍了Windows操作系统的版本及其特点,包括Windows 7系统的6个版本:Starter、Home Basic、Home Premium、Professional、Enterprise、Ultimate。Windows操作系统是微软公司研发的一套操作系统,具有人机操作性优异、支持的应用软件较多、对硬件支持良好等优点。Windows 7 Starter是功能最少的版本,缺乏Aero特效功能,没有64位支持,最初设计不能同时运行三个以上应用程序。 ... [详细]
  • MyBatis多表查询与动态SQL使用
    本文介绍了MyBatis多表查询与动态SQL的使用方法,包括一对一查询和一对多查询。同时还介绍了动态SQL的使用,包括if标签、trim标签、where标签、set标签和foreach标签的用法。文章还提供了相关的配置信息和示例代码。 ... [详细]
  • Windows7 64位系统安装PLSQL Developer的步骤和注意事项
    本文介绍了在Windows7 64位系统上安装PLSQL Developer的步骤和注意事项。首先下载并安装PLSQL Developer,注意不要安装在默认目录下。然后下载Windows 32位的oracle instant client,并解压到指定路径。最后,按照自己的喜好对解压后的文件进行命名和压缩。 ... [详细]
author-avatar
mobiledu2502909113
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有